Coverage Report

Created: 2026-08-07 16:19

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
D:\a\cssh-rs\cssh-rs\xtask\src\release.rs
Line
Count
Source
1
//! Release preparation and git tag creation.
2
//!
3
//! [`prepare_release`] bumps the version, optionally creates a maintenance
4
//! branch, updates `Cargo.toml` and `Cargo.lock`, generates the changelog,
5
//! commits, and pushes.
6
//!
7
//! [`create_release_tag`] validates the current state and creates an annotated
8
//! git tag that triggers the GitHub Actions release workflow.
9
10
use anyhow::{bail, Context, Result};
11
use semver::Version;
12
13
/// Type of version increment for a release.
14
#[derive(Debug, PartialEq)]
15
pub enum ReleaseType {
16
    /// Increment the major component (X.0.0).
17
    Major,
18
    /// Increment the minor component (0.X.0).
19
    Minor,
20
    /// Increment the patch component (0.0.X).
21
    Patch,
22
}
23
24
impl std::fmt::Display for ReleaseType {
25
18
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26
18
        match self {
27
0
            ReleaseType::Major => write!(f, "major"),
28
15
            ReleaseType::Minor => write!(f, "minor"),
29
3
            ReleaseType::Patch => write!(f, "patch"),
30
        }
31
18
    }
32
}
33
34
/// All side-effecting operations required by this module.
35
///
36
/// Each method maps to exactly one external operation, making every step
37
/// independently mockable in tests.
38
pub trait ReleaseSystem {
39
    /// Run `git status --porcelain` and return its stdout.
40
    ///
41
    /// # Errors
42
    ///
43
    /// Returns an error if the process fails.
44
    fn git_status_porcelain(&self) -> Result<String>;
45
46
    /// Return the current git branch name.
47
    ///
48
    /// # Errors
49
    ///
50
    /// Returns an error if the process fails.
51
    fn git_current_branch(&self) -> Result<String>;
52
53
    /// Create and switch to a new branch with `git checkout -b <name>`.
54
    ///
55
    /// # Arguments
56
    ///
57
    /// * `name` - Branch name to create.
58
    ///
59
    /// # Errors
60
    ///
61
    /// Returns an error if the process fails.
62
    fn git_checkout_new_branch(&self, name: &str) -> Result<()>;
63
64
    /// Switch to an existing branch with `git checkout <name>`.
65
    ///
66
    /// Relies on git's DWIM behaviour: when `<name>` exists only as
67
    /// `refs/remotes/origin/<name>`, a local tracking branch is created.
68
    /// The caller is responsible for fetching beforehand.
69
    ///
70
    /// # Arguments
71
    ///
72
    /// * `name` - Branch name to switch to.
73
    ///
74
    /// # Errors
75
    ///
76
    /// Returns an error if the process fails.
77
    fn git_checkout(&self, name: &str) -> Result<()>;
78
79
    /// Return `true` when `refs/heads/<name>` exists locally.
80
    ///
81
    /// # Arguments
82
    ///
83
    /// * `name` - Local branch name to look up.
84
    ///
85
    /// # Errors
86
    ///
87
    /// Returns an error if the process fails for a reason other than the ref
88
    /// not existing.
89
    fn git_branch_exists_local(&self, name: &str) -> Result<bool>;
90
91
    /// Return `true` when `refs/remotes/origin/<name>` exists locally.
92
    ///
93
    /// The remote ref is only present after a successful `git fetch`, so
94
    /// callers must fetch before relying on this answer to reflect the
95
    /// remote's actual state.
96
    ///
97
    /// # Arguments
98
    ///
99
    /// * `name` - Branch name to look up under `origin/`.
100
    ///
101
    /// # Errors
102
    ///
103
    /// Returns an error if the process fails for a reason other than the ref
104
    /// not existing.
105
    fn git_branch_exists_origin(&self, name: &str) -> Result<bool>;
106
107
    /// Stage the given files with `git add`.
108
    ///
109
    /// # Arguments
110
    ///
111
    /// * `files` - Paths to stage.
112
    ///
113
    /// # Errors
114
    ///
115
    /// Returns an error if the process fails.
116
    fn git_add(&self, files: &[String]) -> Result<()>;
117
118
    /// Commit staged changes with the given message.
119
    ///
120
    /// # Arguments
121
    ///
122
    /// * `message` - Commit message.
123
    /// * `no_verify` - When `true`, pass `--no-verify` to bypass git hooks.
124
    ///
125
    /// # Errors
126
    ///
127
    /// Returns an error if the process fails.
128
    fn git_commit(&self, message: &str, no_verify: bool) -> Result<()>;
129
130
    /// Run `git push` with the given extra arguments.
131
    ///
132
    /// # Arguments
133
    ///
134
    /// * `args` - Extra arguments appended to `git push`.
135
    ///
136
    /// # Errors
137
    ///
138
    /// Returns an error if the process fails.
139
    fn git_push(&self, args: &[String]) -> Result<()>;
140
141
    /// Open a GitHub pull request against `base` using `gh pr create --fill`.
142
    ///
143
    /// `--fill` derives title and body from the latest commit, so the caller
144
    /// must ensure that commit has the desired subject/body.
145
    ///
146
    /// # Arguments
147
    ///
148
    /// * `base` - Branch the PR targets.
149
    ///
150
    /// # Errors
151
    ///
152
    /// Returns an error if the process fails.
153
    fn gh_pr_create(&self, base: &str) -> Result<()>;
154
155
    /// Return `git tag -l <tag>` stdout for the given tag name.
156
    ///
157
    /// # Arguments
158
    ///
159
    /// * `tag` - Tag name to check.
160
    ///
161
    /// # Errors
162
    ///
163
    /// Returns an error if the process fails.
164
    fn git_tag_list(&self, tag: &str) -> Result<String>;
165
166
    /// Return the subject of the latest commit (`git log -1 --pretty=format:%s`).
167
    ///
168
    /// # Errors
169
    ///
170
    /// Returns an error if the process fails.
171
    fn git_log_latest_subject(&self) -> Result<String>;
172
173
    /// Run `git fetch`.
174
    ///
175
    /// # Errors
176
    ///
177
    /// Returns an error if the process fails (non-fatal; callers may continue).
178
    fn git_fetch(&self) -> Result<()>;
179
180
    /// Return the number of commits the local branch is behind `<branch>` on
181
    /// the remote.
182
    ///
183
    /// # Arguments
184
    ///
185
    /// * `branch` - Remote branch to compare against.
186
    ///
187
    /// # Errors
188
    ///
189
    /// Returns an error if the process fails.
190
    fn git_rev_list_count_behind(&self, branch: &str) -> Result<u32>;
191
192
    /// Return the number of commits the local branch is ahead of `<branch>`
193
    /// on the remote.
194
    ///
195
    /// # Arguments
196
    ///
197
    /// * `branch` - Remote branch to compare against.
198
    ///
199
    /// # Errors
200
    ///
201
    /// Returns an error if the process fails.
202
    fn git_rev_list_count_ahead(&self, branch: &str) -> Result<u32>;
203
204
    /// Create an annotated git tag.
205
    ///
206
    /// # Arguments
207
    ///
208
    /// * `tag` - Tag name.
209
    /// * `message` - Annotation message.
210
    ///
211
    /// # Errors
212
    ///
213
    /// Returns an error if the process fails.
214
    fn git_create_annotated_tag(&self, tag: &str, message: &str) -> Result<()>;
215
216
    /// Push a tag to `origin`.
217
    ///
218
    /// # Arguments
219
    ///
220
    /// * `tag` - Tag name to push.
221
    ///
222
    /// # Errors
223
    ///
224
    /// Returns an error if the process fails.
225
    fn git_push_tag(&self, tag: &str) -> Result<()>;
226
227
    /// Read the contents of `Cargo.toml`.
228
    ///
229
    /// # Errors
230
    ///
231
    /// Returns an error if the file cannot be read.
232
    fn read_cargo_toml(&self) -> Result<String>;
233
234
    /// Write `content` to `Cargo.toml`.
235
    ///
236
    /// # Errors
237
    ///
238
    /// Returns an error if the write fails.
239
    fn write_cargo_toml(&self, content: &str) -> Result<()>;
240
241
    /// Run `cargo update --workspace` to refresh `Cargo.lock`.
242
    ///
243
    /// # Errors
244
    ///
245
    /// Returns an error if the process fails.
246
    fn cargo_update_workspace(&self) -> Result<()>;
247
248
    /// Generate the changelog for the current version.
249
    ///
250
    /// # Errors
251
    ///
252
    /// Returns an error if changelog generation fails.
253
    fn generate_changelog(&self) -> Result<()>;
254
255
    /// Display `message` and read a line of user input.
256
    ///
257
    /// # Arguments
258
    ///
259
    /// * `message` - Prompt text.
260
    ///
261
    /// # Returns
262
    ///
263
    /// The trimmed response string.
264
    ///
265
    /// # Errors
266
    ///
267
    /// Returns an error if stdin cannot be read.
268
    fn prompt_user(&self, message: &str) -> Result<String>;
269
}
270
271
/// Check whether `ref_name` exists via `git show-ref --verify`.
272
///
273
/// `git show-ref` is documented to exit 0 when the ref exists, 1 when it does
274
/// not, and other non-zero codes for actual failures (bad arguments, broken
275
/// repo, etc.). Mapping every non-zero exit to "missing" would silently
276
/// swallow real errors, so the latter must surface as an `Err`.
277
#[cfg_attr(coverage_nightly, coverage(off))]
278
fn show_ref_exists(ref_name: &str) -> Result<bool> {
279
    let output = std::process::Command::new("git")
280
        .args(["show-ref", "--verify", "--quiet", ref_name])
281
        .output()
282
        .context("failed to run `git show-ref`")?;
283
    match output.status.code() {
284
        Some(0) => Ok(true),
285
        Some(1) => Ok(false),
286
        _ => bail!(
287
            "`git show-ref --verify {ref_name}` failed with status {}: {}",
288
            output.status,
289
            String::from_utf8_lossy(&output.stderr).trim(),
290
        ),
291
    }
292
}
293
294
/// Run `git rev-list --count <range>` and return the parsed commit count.
295
///
296
/// A non-zero exit (e.g. unknown ref) or unparseable stdout must surface as an
297
/// `Err` - returning `0` would silently mask "stale ref" or "git failed" as
298
/// "branch is up to date".
299
#[cfg_attr(coverage_nightly, coverage(off))]
300
fn rev_list_count(range: &str) -> Result<u32> {
301
    let output = std::process::Command::new("git")
302
        .args(["rev-list", "--count", range])
303
        .output()
304
        .context("failed to run `git rev-list`")?;
305
    if !output.status.success() {
306
        bail!(
307
            "`git rev-list --count {range}` failed with status {}: {}",
308
            output.status,
309
            String::from_utf8_lossy(&output.stderr).trim(),
310
        );
311
    }
312
    let stdout = String::from_utf8_lossy(&output.stdout);
313
    stdout.trim().parse::<u32>().with_context(|| {
314
        format!("failed to parse `git rev-list --count {range}` stdout: {stdout:?}")
315
    })
316
}
317
318
/// Production implementation of [`ReleaseSystem`].
319
pub struct RealSystem;
320
321
#[cfg_attr(coverage_nightly, coverage(off))]
322
impl ReleaseSystem for RealSystem {
323
    fn git_status_porcelain(&self) -> Result<String> {
324
        let output = std::process::Command::new("git")
325
            .args(["status", "--porcelain"])
326
            .output()
327
            .context("failed to run `git status --porcelain`")?;
328
        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
329
    }
330
331
    fn git_current_branch(&self) -> Result<String> {
332
        let output = std::process::Command::new("git")
333
            .args(["branch", "--show-current"])
334
            .output()
335
            .context("failed to run `git branch --show-current`")?;
336
        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
337
    }
338
339
    fn git_checkout_new_branch(&self, name: &str) -> Result<()> {
340
        let status = std::process::Command::new("git")
341
            .args(["checkout", "-b", name])
342
            .status()
343
            .context("failed to run `git checkout -b`")?;
344
        if !status.success() {
345
            bail!("`git checkout -b {name}` failed with status {status}");
346
        }
347
        Ok(())
348
    }
349
350
    fn git_checkout(&self, name: &str) -> Result<()> {
351
        let status = std::process::Command::new("git")
352
            .args(["checkout", name])
353
            .status()
354
            .context("failed to run `git checkout`")?;
355
        if !status.success() {
356
            bail!("`git checkout {name}` failed with status {status}");
357
        }
358
        Ok(())
359
    }
360
361
    fn git_branch_exists_local(&self, name: &str) -> Result<bool> {
362
        show_ref_exists(&format!("refs/heads/{name}"))
363
    }
364
365
    fn git_branch_exists_origin(&self, name: &str) -> Result<bool> {
366
        show_ref_exists(&format!("refs/remotes/origin/{name}"))
367
    }
368
369
    fn git_add(&self, files: &[String]) -> Result<()> {
370
        let status = std::process::Command::new("git")
371
            .arg("add")
372
            .args(files)
373
            .status()
374
            .context("failed to run `git add`")?;
375
        if !status.success() {
376
            bail!("`git add` failed with status {status}");
377
        }
378
        Ok(())
379
    }
380
381
    fn git_commit(&self, message: &str, no_verify: bool) -> Result<()> {
382
        let mut cmd = std::process::Command::new("git");
383
        cmd.args(["commit", "-m", message]);
384
        if no_verify {
385
            cmd.arg("--no-verify");
386
        }
387
        let status = cmd.status().context("failed to run `git commit`")?;
388
        if !status.success() {
389
            bail!("`git commit` failed with status {status}");
390
        }
391
        Ok(())
392
    }
393
394
    fn git_push(&self, args: &[String]) -> Result<()> {
395
        let status = std::process::Command::new("git")
396
            .arg("push")
397
            .args(args)
398
            .status()
399
            .context("failed to run `git push`")?;
400
        if !status.success() {
401
            bail!("`git push` failed with status {status}");
402
        }
403
        Ok(())
404
    }
405
406
    fn gh_pr_create(&self, base: &str) -> Result<()> {
407
        let status = std::process::Command::new("gh")
408
            .args(["pr", "create", "--base", base, "--fill"])
409
            .status()
410
            .context("failed to run `gh pr create`")?;
411
        if !status.success() {
412
            bail!("`gh pr create --base {base}` failed with status {status}");
413
        }
414
        Ok(())
415
    }
416
417
    fn git_tag_list(&self, tag: &str) -> Result<String> {
418
        let output = std::process::Command::new("git")
419
            .args(["tag", "-l", tag])
420
            .output()
421
            .context("failed to run `git tag -l`")?;
422
        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
423
    }
424
425
    fn git_log_latest_subject(&self) -> Result<String> {
426
        let output = std::process::Command::new("git")
427
            .args(["log", "-1", "--pretty=format:%s"])
428
            .output()
429
            .context("failed to run `git log`")?;
430
        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
431
    }
432
433
    fn git_fetch(&self) -> Result<()> {
434
        let status = std::process::Command::new("git")
435
            .arg("fetch")
436
            .status()
437
            .context("failed to run `git fetch`")?;
438
        if !status.success() {
439
            bail!("`git fetch` failed with status {status}");
440
        }
441
        Ok(())
442
    }
443
444
    fn git_rev_list_count_behind(&self, branch: &str) -> Result<u32> {
445
        rev_list_count(&format!("HEAD..origin/{branch}"))
446
    }
447
448
    fn git_rev_list_count_ahead(&self, branch: &str) -> Result<u32> {
449
        rev_list_count(&format!("origin/{branch}..HEAD"))
450
    }
451
452
    fn git_create_annotated_tag(&self, tag: &str, message: &str) -> Result<()> {
453
        let status = std::process::Command::new("git")
454
            .args(["tag", "-a", tag, "-m", message])
455
            .status()
456
            .context("failed to run `git tag -a`")?;
457
        if !status.success() {
458
            bail!("`git tag -a {tag}` failed with status {status}");
459
        }
460
        Ok(())
461
    }
462
463
    fn git_push_tag(&self, tag: &str) -> Result<()> {
464
        let status = std::process::Command::new("git")
465
            .args(["push", "origin", tag])
466
            .status()
467
            .context("failed to run `git push origin <tag>`")?;
468
        if !status.success() {
469
            bail!("`git push origin {tag}` failed with status {status}");
470
        }
471
        Ok(())
472
    }
473
474
    fn read_cargo_toml(&self) -> Result<String> {
475
        std::fs::read_to_string("Cargo.toml").context("failed to read Cargo.toml")
476
    }
477
478
    fn write_cargo_toml(&self, content: &str) -> Result<()> {
479
        std::fs::write("Cargo.toml", content).context("failed to write Cargo.toml")
480
    }
481
482
    fn cargo_update_workspace(&self) -> Result<()> {
483
        let status = std::process::Command::new("cargo")
484
            .args(["update", "--workspace"])
485
            .status()
486
            .context("failed to run `cargo update --workspace`")?;
487
        if !status.success() {
488
            bail!("`cargo update --workspace` failed with status {status}");
489
        }
490
        Ok(())
491
    }
492
493
    fn generate_changelog(&self) -> Result<()> {
494
        crate::changelog::generate_changelog(&crate::changelog::RealSystem)
495
    }
496
497
    fn prompt_user(&self, message: &str) -> Result<String> {
498
        use std::io::Write;
499
        // Interactive prompt: written directly to stdout so it lacks the
500
        // timestamp/level prefix the logger would add, and flushed so the
501
        // cursor sits next to the prompt before stdin is read.
502
        print!("{message}");
503
        std::io::stdout()
504
            .flush()
505
            .context("failed to flush stdout")?;
506
        let mut input = String::new();
507
        std::io::stdin()
508
            .read_line(&mut input)
509
            .context("failed to read user input")?;
510
        Ok(input.trim().to_owned())
511
    }
512
}
513
514
/// Determine the suggested next version and release type from the current branch.
515
///
516
/// `main` -> minor bump; `*-maintenance` -> patch bump.
517
///
518
/// # Arguments
519
///
520
/// * `current` - Current version from `Cargo.toml`.
521
/// * `branch` - Current git branch name.
522
///
523
/// # Returns
524
///
525
/// `(ReleaseType, next_version)`.
526
///
527
/// # Errors
528
///
529
/// Returns an error when `branch` is neither `main` nor ends with
530
/// `-maintenance`.
531
13
pub fn suggest_next_version(current: &Version, branch: &str) -> Result<(ReleaseType, Version)> {
532
13
    if branch == "main" {
533
9
        let mut next = current.clone();
534
9
        next.minor += 1;
535
9
        next.patch = 0;
536
9
        Ok((ReleaseType::Minor, next))
537
4
    } else if branch.ends_with("-maintenance") {
538
2
        let mut next = current.clone();
539
2
        next.patch += 1;
540
2
        Ok((ReleaseType::Patch, next))
541
    } else {
542
2
        bail!(
543
            "must be on 'main' or a '*-maintenance' branch to prepare a release \
544
             (current branch: {branch})"
545
        )
546
    }
547
13
}
548
549
/// Determine the release type by comparing two versions.
550
///
551
/// # Arguments
552
///
553
/// * `current` - The version before the release.
554
/// * `next` - The version after the release.
555
///
556
/// # Returns
557
///
558
/// The most significant component that changed.
559
4
pub fn determine_release_type(current: &Version, next: &Version) -> ReleaseType {
560
4
    if next.major > current.major {
561
1
        ReleaseType::Major
562
3
    } else if next.minor > current.minor {
563
1
        ReleaseType::Minor
564
    } else {
565
2
        ReleaseType::Patch
566
    }
567
4
}
568
569
/// Rewrite the `[workspace.package].version` field in a `Cargo.toml` string.
570
///
571
/// Uses `toml_edit` to preserve all existing formatting.
572
///
573
/// # Arguments
574
///
575
/// * `cargo_toml_content` - Raw TOML text of `Cargo.toml`.
576
/// * `new_version` - Version string to set.
577
///
578
/// # Returns
579
///
580
/// Updated TOML text.
581
///
582
/// # Errors
583
///
584
/// Returns an error if `cargo_toml_content` cannot be parsed as TOML or the
585
/// `[workspace.package].version` key is absent.
586
9
pub fn set_cargo_toml_version(cargo_toml_content: &str, new_version: &str) -> Result<String> {
587
9
    let mut doc: toml_edit::DocumentMut = cargo_toml_content
588
9
        .parse()
589
9
        .context("failed to parse Cargo.toml")
?0
;
590
9
    let 
version8
= doc
591
9
        .get_mut("workspace")
592
9
        .and_then(|w| w.as_table_mut())
593
9
        .and_then(|t| t.get_mut("package"))
594
9
        .and_then(|p| 
p8
.
as_table_mut8
())
595
9
        .and_then(|t| 
t8
.
get_mut8
(
"version"8
))
596
9
        .context("missing [workspace.package].version in Cargo.toml")
?1
;
597
8
    *version = toml_edit::value(new_version);
598
8
    Ok(doc.to_string())
599
9
}
600
601
/// Ensure the maintenance branch exists and is checked out before any release
602
/// prepared from `main` (major/minor create + push the maintenance branch and
603
/// then branch off `release-X.Y.Z`; a custom patch version typed on `main`
604
/// switches to an existing maintenance branch and pushes the version bump
605
/// directly).
606
///
607
/// Fetches once, then handles the four
608
/// (local exists, origin exists) combinations:
609
///
610
/// - `(false, false)`: create the branch from the current HEAD (`main`) and
611
///   push it to `origin`.
612
/// - `(true, false)`: switch to the existing local branch and push it.
613
/// - `(false, true)`: switch to the branch - git's DWIM creates a local
614
///   tracking branch from `origin/<name>`.
615
/// - `(true, true)`: switch to the local branch and verify it is neither
616
///   behind nor ahead of `origin`.
617
///
618
/// A failed `git fetch` is fatal here: every subsequent decision depends on
619
/// `refs/remotes/origin/*` reflecting the remote's actual state, and a stale
620
/// view can cause the wrong branch (create / push / checkout / fail-behind)
621
/// to be taken.
622
///
623
/// # Arguments
624
///
625
/// * `system` - Injected I/O provider.
626
/// * `maintenance_branch` - Name of the maintenance branch to ready.
627
///
628
/// # Errors
629
///
630
/// Returns an error if any git step fails or the local branch is behind or
631
/// ahead of origin.
632
8
fn ensure_maintenance_branch_ready<S: ReleaseSystem>(
633
8
    system: &S,
634
8
    maintenance_branch: &str,
635
8
) -> Result<()> {
636
8
    log::info!("Fetching origin to check maintenance branch state");
637
8
    system
638
8
        .git_fetch()
639
8
        .context("failed to fetch from origin - cannot determine maintenance branch state")
?1
;
640
641
7
    let local_exists = system.git_branch_exists_local(maintenance_branch)
?0
;
642
7
    let origin_exists = system.git_branch_exists_origin(maintenance_branch)
?0
;
643
644
7
    match (local_exists, origin_exists) {
645
        (false, false) => {
646
1
            log::info!(
647
                "Maintenance branch {maintenance_branch} does not exist; \
648
                 creating from current HEAD and pushing to origin"
649
            );
650
1
            system.git_checkout_new_branch(maintenance_branch)
?0
;
651
1
            system.git_push(&[
652
1
                "-u".to_owned(),
653
1
                "origin".to_owned(),
654
1
                maintenance_branch.to_owned(),
655
1
            ])
?0
;
656
        }
657
        (true, false) => {
658
1
            log::info!(
659
                "Maintenance branch {maintenance_branch} exists locally only; \
660
                 switching to it and pushing to origin"
661
            );
662
1
            system.git_checkout(maintenance_branch)
?0
;
663
1
            system.git_push(&[
664
1
                "-u".to_owned(),
665
1
                "origin".to_owned(),
666
1
                maintenance_branch.to_owned(),
667
1
            ])
?0
;
668
        }
669
        (false, true) => {
670
1
            log::info!(
671
                "Maintenance branch {maintenance_branch} exists on origin only; \
672
                 creating a local tracking branch"
673
            );
674
1
            system.git_checkout(maintenance_branch)
?0
;
675
        }
676
        (true, true) => {
677
4
            log::info!(
678
                "Maintenance branch {maintenance_branch} exists locally and on \
679
                 origin; switching to local branch"
680
            );
681
4
            system.git_checkout(maintenance_branch)
?0
;
682
4
            let behind = system.git_rev_list_count_behind(maintenance_branch)
?0
;
683
4
            if behind > 0 {
684
1
                bail!(
685
                    "local maintenance branch {maintenance_branch} is {behind} commit(s) \
686
                     behind origin - run `git pull` first"
687
                );
688
3
            }
689
            // Unpushed local commits would otherwise leak into the release PR
690
            // when we branch off `release-X.Y.Z` from here.
691
3
            let ahead = system.git_rev_list_count_ahead(maintenance_branch)
?0
;
692
3
            if ahead > 0 {
693
1
                bail!(
694
                    "local maintenance branch {maintenance_branch} is {ahead} commit(s) \
695
                     ahead of origin - push it before preparing a release"
696
                );
697
2
            }
698
        }
699
    }
700
5
    Ok(())
701
8
}
702
703
/// Prepare a new release.
704
///
705
/// Full workflow:
706
/// 1. Verify working tree is clean.
707
/// 2. Detect branch and suggest release type / next version.
708
/// 3. Prompt user (accepts custom version input).
709
/// 4. When releasing from `main`: ensure the target maintenance branch is
710
///    ready (see [`ensure_maintenance_branch_ready`]). For a major/minor
711
///    release this creates the branch when missing; for a patch release
712
///    entered as a custom version it switches to the existing branch.
713
///    Then, for a major/minor release, branch off a `release-X.Y.Z` branch
714
///    for the version bump. For a patch release on a maintenance branch
715
///    (or switched to one above), stay on that branch.
716
/// 5. Update `Cargo.toml` version.
717
/// 6. Run `cargo update --workspace`.
718
/// 7. Generate changelog.
719
/// 8. Commit the version bump.
720
/// 9. For a major/minor release from `main`: push the `release-X.Y.Z`
721
///    branch and open a GH PR against the maintenance branch. For a patch
722
///    release: push directly to the maintenance branch.
723
///
724
/// # Arguments
725
///
726
/// * `system` - Injected I/O provider.
727
///
728
/// # Errors
729
///
730
/// Returns an error if any step fails.
731
11
pub fn prepare_release<S: ReleaseSystem>(system: &S) -> Result<()> {
732
11
    let status = system.git_status_porcelain()
?0
;
733
11
    if !status.trim().is_empty() {
734
1
        bail!("git working directory is not clean - commit or stash changes first:\n{status}");
735
10
    }
736
737
10
    let current_branch = system.git_current_branch()
?0
;
738
10
    let cargo_toml = system.read_cargo_toml()
?0
;
739
10
    let current_version: Version = crate::changelog::extract_version_from_cargo_toml(&cargo_toml)
?0
740
10
        .parse()
741
10
        .context("failed to parse current version as semver")
?0
;
742
743
10
    log::info!("Current branch: {current_branch}");
744
10
    log::info!("Current version: {current_version}");
745
746
9
    let (suggested_type, suggested_version) =
747
10
        suggest_next_version(&current_version, &current_branch)
?1
;
748
749
9
    let prompt = format!(
750
        "Preparing {suggested_type} release: {current_version} -> {suggested_version}. Continue? [Y/n]: "
751
    );
752
9
    let answer = system.prompt_user(&prompt)
?0
;
753
754
9
    let (next_version, actual_type) =
755
9
        if answer.eq_ignore_ascii_case("n") || 
answer8
.eq_ignore_ascii_case("no") {
756
1
            let custom_str = system.prompt_user(&format!(
757
1
                "Enter custom version (current: {current_version}): "
758
1
            ))
?0
;
759
1
            if custom_str.is_empty() {
760
0
                bail!("version cannot be empty");
761
1
            }
762
1
            let custom: Version = custom_str
763
1
                .parse()
764
1
                .context("invalid version format - use semantic versioning (e.g. 1.2.3)")
?0
;
765
1
            let release_type = determine_release_type(&current_version, &custom);
766
1
            (custom, release_type)
767
8
        } else if answer.is_empty()
768
8
            || answer.eq_ignore_ascii_case("y")
769
0
            || answer.eq_ignore_ascii_case("yes")
770
        {
771
8
            (suggested_version, suggested_type)
772
        } else {
773
0
            bail!("invalid input - please enter Y or n");
774
        };
775
776
9
    let releases_from_main = current_branch == "main";
777
9
    let opens_pr =
778
9
        releases_from_main && 
matches!1
(
actual_type8
, ReleaseType::Major | ReleaseType::Minor);
779
9
    let maintenance_branch = if releases_from_main {
780
8
        format!("{}.{}-maintenance", next_version.major, next_version.minor)
781
    } else {
782
1
        current_branch.clone()
783
    };
784
9
    let pr_branch = opens_pr.then(|| 
format!7
("release-{next_version}"));
785
786
9
    log::info!("Preparing {actual_type} release: {current_version} -> {next_version}");
787
9
    log::info!("Maintenance branch: {maintenance_branch}");
788
789
9
    if releases_from_main {
790
8
        ensure_maintenance_branch_ready(system, &maintenance_branch)
?3
;
791
1
    }
792
793
6
    if let Some(
pr_branch_name4
) = pr_branch.as_deref() {
794
4
        log::info!("Creating release branch: {pr_branch_name}");
795
4
        system.git_checkout_new_branch(pr_branch_name)
?0
;
796
2
    }
797
798
6
    log::info!("Updating Cargo.toml version to {next_version}");
799
6
    let updated_cargo = set_cargo_toml_version(&cargo_toml, &next_version.to_string())
?0
;
800
6
    system.write_cargo_toml(&updated_cargo)
?0
;
801
802
6
    log::info!("Updating Cargo.lock");
803
6
    system.cargo_update_workspace()
?0
;
804
805
6
    log::info!("Generating changelog");
806
6
    system.generate_changelog()
?0
;
807
808
6
    let commit_message = format!("Version {next_version}");
809
6
    log::info!("Committing: {commit_message}");
810
6
    system.git_add(&[
811
6
        "Cargo.toml".to_owned(),
812
6
        "Cargo.lock".to_owned(),
813
6
        "CHANGELOG.md".to_owned(),
814
6
        "changelogging.toml".to_owned(),
815
6
    ])
?0
;
816
    // Skip pre-commit hooks: the project's hook runs `cargo build --workspace
817
    // --all-targets`, which would try to replace the running xtask.exe and
818
    // fail on Windows with an access-denied error.
819
6
    system.git_commit(&commit_message, true)
?0
;
820
821
6
    if let Some(
pr_branch_name4
) = pr_branch.as_deref() {
822
4
        log::info!("Pushing release branch: {pr_branch_name}");
823
4
        system.git_push(&[
824
4
            "-u".to_owned(),
825
4
            "origin".to_owned(),
826
4
            pr_branch_name.to_owned(),
827
4
        ])
?0
;
828
829
4
        log::info!("Opening PR against {maintenance_branch}");
830
4
        system.gh_pr_create(&maintenance_branch)
?0
;
831
832
4
        log::info!(
833
            "Release {next_version} prepared on branch {pr_branch_name} \
834
             with PR against {maintenance_branch}"
835
        );
836
4
        log::info!(
837
            "After the PR is merged, switch to {maintenance_branch}, \
838
             pull, and run `cargo xtask create-release-tag` to tag the release"
839
        );
840
    } else {
841
2
        log::info!("Pushing to remote");
842
2
        system.git_push(&[])
?0
;
843
844
2
        log::info!("Release {next_version} prepared on branch {maintenance_branch}");
845
2
        log::info!("Run `cargo xtask create-release-tag` to tag the release");
846
    }
847
6
    Ok(())
848
11
}
849
850
/// Create and push an annotated git tag for the current release version.
851
///
852
/// Full workflow:
853
/// 1. Verify on a maintenance branch.
854
/// 2. Read version from `Cargo.toml`.
855
/// 3. Check the tag does not already exist.
856
/// 4. Verify the latest commit message is `"Version X.Y.Z"`.
857
/// 5. Fetch from remote and check not behind.
858
/// 6. Prompt user for confirmation.
859
/// 7. Create annotated tag and push.
860
///
861
/// # Arguments
862
///
863
/// * `system` - Injected I/O provider.
864
///
865
/// # Errors
866
///
867
/// Returns an error if any validation step fails.
868
6
pub fn create_release_tag<S: ReleaseSystem>(system: &S) -> Result<()> {
869
6
    let current_branch = system.git_current_branch()
?0
;
870
6
    if !current_branch.ends_with("-maintenance") {
871
1
        bail!(
872
            "must be on a maintenance branch to create a release tag \
873
             (current branch: {current_branch}) - run `cargo xtask prepare-release` first"
874
        );
875
5
    }
876
877
5
    let cargo_toml = system.read_cargo_toml()
?0
;
878
5
    let version_str = crate::changelog::extract_version_from_cargo_toml(&cargo_toml)
?0
;
879
5
    let version: Version = version_str
880
5
        .parse()
881
5
        .context("failed to parse version as semver")
?0
;
882
883
5
    log::info!("Current branch: {current_branch}");
884
5
    log::info!("Version to tag: {version}");
885
886
5
    let existing_tag = system.git_tag_list(&version.to_string())
?0
;
887
5
    if !existing_tag.trim().is_empty() {
888
1
        bail!("tag {version} already exists");
889
4
    }
890
891
4
    let commit_msg = system.git_log_latest_subject()
?0
;
892
4
    let expected_msg = format!("Version {version}");
893
4
    if commit_msg != expected_msg {
894
1
        bail!(
895
            "latest commit message does not match expected version commit\n\
896
             expected: {expected_msg}\n\
897
             actual:   {commit_msg}\n\
898
             run `cargo xtask prepare-release` first"
899
        );
900
3
    }
901
902
3
    log::info!("Fetching latest changes from remote");
903
3
    if let Err(
e0
) = system.git_fetch() {
904
0
        log::warn!("Failed to fetch from remote, continuing anyway: {e}");
905
3
    }
906
907
3
    let behind = system.git_rev_list_count_behind(&current_branch)
?0
;
908
3
    if behind > 0 {
909
1
        bail!("local branch is {behind} commit(s) behind remote - run `git pull` first");
910
2
    }
911
912
2
    let answer = system.prompt_user(&format!(
913
2
        "About to create and push tag '{version}'. Continue? [Y/n]: "
914
2
    ))
?0
;
915
2
    if answer.eq_ignore_ascii_case("n") || 
answer1
.eq_ignore_ascii_case("no") {
916
1
        log::info!("Tag creation cancelled");
917
1
        return Ok(());
918
1
    }
919
920
1
    let tag_message = format!("Version {version}");
921
1
    log::info!("Creating annotated tag: {version}");
922
1
    system.git_create_annotated_tag(&version.to_string(), &tag_message)
?0
;
923
924
1
    log::info!("Pushing tag to remote");
925
1
    system.git_push_tag(&version.to_string())
?0
;
926
927
1
    log::info!("Tag '{version}' created and pushed");
928
1
    log::info!("Check: https://github.com/whmade/cssh-rs/actions/workflows/release.yml");
929
1
    Ok(())
930
6
}
931
932
#[cfg(test)]
933
#[path = "tests/test_release.rs"]
934
mod tests;